145d75faf08a28e48e4e2fd1381a263e8e129470
[project/luci.git] /
1 'use strict';
2 'require view';
3 'require form';
4 'require uci';
5 'require rpc';
6 'require ui';
7 'require poll';
8 'require request';
9 'require dom';
10 'require fs';
11
12 const callPackagelist = rpc.declare({
13 object: 'rpc-sys',
14 method: 'packagelist',
15 });
16
17 const callSystemBoard = rpc.declare({
18 object: 'system',
19 method: 'board',
20 });
21
22 const callUpgradeStart = rpc.declare({
23 object: 'rpc-sys',
24 method: 'upgrade_start',
25 params: ['keep'],
26 });
27
28 /**
29 * Returns the branch of a given version. This helps to offer upgrades
30 * for point releases (aka within the branch).
31 *
32 * Logic:
33 * SNAPSHOT -> SNAPSHOT
34 * 21.02-SNAPSHOT -> 21.02
35 * 21.02.0-rc1 -> 21.02
36 * 19.07.8 -> 19.07
37 *
38 * @param {string} version
39 * Input version from which to determine the branch
40 * @returns {string}
41 * The determined branch
42 */
43 function get_branch(version) {
44 return version.replace('-SNAPSHOT', '').split('.').slice(0, 2).join('.');
45 }
46
47 /**
48 * The OpenWrt revision string contains both a hash as well as the number
49 * commits since the OpenWrt/LEDE reboot. It helps to determine if a
50 * snapshot is newer than another.
51 *
52 * @param {string} revision
53 * Revision string of a OpenWrt device
54 * @returns {integer}
55 * The number of commits since OpenWrt/LEDE reboot
56 */
57 function get_revision_count(revision) {
58 return parseInt(revision.substring(1).split('-')[0]);
59 }
60
61 return view.extend({
62 steps: {
63 init: [ 0, _('Received build request')],
64 container_setup: [ 10, _('Setting up ImageBuilder')],
65 validate_revision: [ 20, _('Validating revision')],
66 validate_manifest: [ 30, _('Validating package selection')],
67 calculate_packages_hash: [ 40, _('Calculating package hash')],
68 building_image: [ 50, _('Generating firmware image')],
69 signing_images: [ 95, _('Signing images')],
70 done: [100, _('Completed generating firmware image')],
71 failed: [100, _('Failed to generate firmware image')],
72
73 /* Obsolete status values, retained for backward compatibility. */
74 download_imagebuilder: [ 20, _('Downloading ImageBuilder archive')],
75 unpack_imagebuilder: [ 40, _('Setting Up ImageBuilder')],
76 },
77
78 request_hash: '',
79 sha256_unsigned: '',
80
81 applyPackageChanges: async function(package_info) {
82 let { url, target, version, packages } = package_info;
83
84 const overview_url = `${url}/api/v1/overview`;
85 const revision_url = `${url}/api/v1/revision/${version}/${target}`;
86
87 let changes, target_revision;
88
89 await Promise.all([
90 request.get(overview_url).then(
91 (response) => {
92 let json = response.json();
93 changes = json.branches[get_branch(version)].package_changes;
94 },
95 (failed) => {
96 ui.addNotification(null, E('p', _(`Get overview failed ${failed}`)));
97 }
98 ),
99 request.get(revision_url).then(
100 (response) => {
101 target_revision = get_revision_count(response.json().revision);
102 },
103 (failed) => {
104 ui.addNotification(null, E('p', _(`Get revision failed ${failed}`)));
105 }
106 ),
107 ]);
108
109 for (const change of changes) {
110 let idx = packages.indexOf(change.source);
111 if (idx >= 0 && change.revision <= target_revision) {
112 if (change.target)
113 packages[idx] = change.target;
114 else
115 packages.splice(idx, 1);
116 }
117 }
118 return packages;
119 },
120
121 selectImage: function (images, data, firmware) {
122 var filesystemFilter = function(e) {
123 return (e.filesystem == firmware.filesystem);
124 }
125 var typeFilter = function(e) {
126 let efi_targets = ['armsr', 'loongarch', 'x86'];
127 let efi_capable = efi_targets.some((tgt) => firmware.target.startsWith(tgt));
128 if (efi_capable) {
129 if (data.efi) {
130 return (e.type == 'combined-efi');
131 } else {
132 return (e.type == 'combined');
133 }
134 } else {
135 return (e.type == 'sysupgrade' || e.type == 'combined');
136 }
137 }
138 return images.filter(filesystemFilter).filter(typeFilter)[0];
139 },
140
141 handle200: function (response, content, data, firmware) {
142 response = response.json();
143 let image = this.selectImage(response.images, data, firmware);
144
145 if (image.name != undefined) {
146 this.sha256_unsigned = image.sha256_unsigned;
147 let sysupgrade_url = `${data.url}/store/${response.bin_dir}/${image.name}`;
148
149 let keep = E('input', { type: 'checkbox' });
150 keep.checked = true;
151
152 let fields = [
153 _('Version'),
154 `${response.version_number} ${response.version_code}`,
155 _('SHA256'),
156 image.sha256,
157 ];
158
159 if (data.advanced_mode == 1) {
160 fields.push(
161 _('Profile'),
162 response.id,
163 _('Target'),
164 response.target,
165 _('Build Date'),
166 response.build_at,
167 _('Filename'),
168 image.name,
169 _('Filesystem'),
170 image.filesystem
171 );
172 }
173
174 fields.push(
175 '',
176 E('a', { href: sysupgrade_url }, _('Download firmware image'))
177 );
178 if (data.rebuilder) {
179 fields.push(_('Rebuilds'), E('div', { id: 'rebuilder_status' }));
180 }
181
182 let table = E('div', { class: 'table' });
183
184 for (let i = 0; i < fields.length; i += 2) {
185 table.appendChild(
186 E('tr', { class: 'tr' }, [
187 E('td', { class: 'td left', width: '33%' }, [fields[i]]),
188 E('td', { class: 'td left' }, [fields[i + 1]]),
189 ])
190 );
191 }
192
193 let modal_body = [
194 table,
195 E(
196 'p',
197 { class: 'mt-2' },
198 E('label', { class: 'btn' }, [
199 keep,
200 ' ',
201 _('Keep settings and retain the current configuration'),
202 ])
203 ),
204 E('div', { class: 'right' }, [
205 E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
206 ' ',
207 E(
208 'button',
209 {
210 class: 'btn cbi-button cbi-button-positive important',
211 click: ui.createHandlerFn(this, function () {
212 this.handleInstall(sysupgrade_url, keep.checked, image.sha256);
213 }),
214 },
215 _('Install firmware image')
216 ),
217 ]),
218 ];
219
220 ui.showModal(_('Successfully created firmware image'), modal_body);
221 if (data.rebuilder) {
222 this.handleRebuilder(content, data, firmware);
223 }
224 }
225 },
226
227 handle202: function (response) {
228 response = response.json();
229 this.request_hash = response.request_hash;
230
231 if ('queue_position' in response) {
232 ui.showModal(_('Queued...'), [
233 E(
234 'p',
235 { class: 'spinning' },
236 _('Request in build queue position %s').format(
237 response.queue_position
238 )
239 ),
240 ]);
241 } else {
242 ui.showModal(_('Building Firmware...'), [
243 E(
244 'p',
245 { class: 'spinning' },
246 _('Progress: %s%% %s').format(
247 this.steps[response.imagebuilder_status][0],
248 this.steps[response.imagebuilder_status][1]
249 )
250 ),
251 ]);
252 }
253 },
254
255 handleError: function (response, data, firmware) {
256 response = response.json();
257 const request_data = {
258 ...data,
259 request_hash: this.request_hash,
260 sha256_unsigned: this.sha256_unsigned,
261 ...firmware
262 };
263 let body = [
264 E('p', {}, _('Server response: %s').format(response.detail)),
265 E(
266 'a',
267 { href: 'https://github.com/openwrt/asu/issues' },
268 _('Please report the error message and request')
269 ),
270 E('p', {}, _('Request Data:')),
271 E('pre', {}, JSON.stringify({ ...request_data }, null, 4)),
272 ];
273
274 if (response.stdout) {
275 body.push(E('b', {}, 'STDOUT:'));
276 body.push(E('pre', {}, response.stdout));
277 }
278
279 if (response.stderr) {
280 body.push(E('b', {}, 'STDERR:'));
281 body.push(E('pre', {}, response.stderr));
282 }
283
284 body = body.concat([
285 E('div', { class: 'right' }, [
286 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
287 ]),
288 ]);
289
290 ui.showModal(_('Error building the firmware image'), body);
291 },
292
293 handleRequest: function (server, main, content, data, firmware) {
294 let request_url = `${server}/api/v1/build`;
295 let method = 'POST';
296 let local_content = content;
297
298 /**
299 * If `request_hash` is available use a GET request instead of
300 * sending the entire object.
301 */
302 if (this.request_hash && main == true) {
303 request_url += `/${this.request_hash}`;
304 local_content = {};
305 method = 'GET';
306 }
307
308 request
309 .request(request_url, { method: method, content: local_content })
310 .then((response) => {
311 switch (response.status) {
312 case 202:
313 if (main) {
314 this.handle202(response);
315 } else {
316 response = response.json();
317
318 let view = document.getElementById(server);
319 view.innerText = `⏳ (${
320 this.steps[response.imagebuilder_status][0]
321 }%) ${server}`;
322 }
323 break;
324 case 200:
325 if (main == true) {
326 poll.remove(this.pollFn);
327 this.handle200(response, content, data, firmware);
328 } else {
329 poll.remove(this.rebuilder_polls[server]);
330 response = response.json();
331 let view = document.getElementById(server);
332 let image = this.selectImage(response.images, data, firmware);
333 if (image.sha256_unsigned == this.sha256_unsigned) {
334 view.innerText = '✅ %s'.format(server);
335 } else {
336 view.innerHTML = `⚠️ ${server} (<a href="${server}/store/${
337 response.bin_dir
338 }/${image.name}">${_('Download')}</a>)`;
339 }
340 }
341 break;
342 case 400: // bad request
343 case 422: // bad package
344 case 500: // build failed
345 if (main == true) {
346 poll.remove(this.pollFn);
347 this.handleError(response, data, firmware);
348 break;
349 } else {
350 poll.remove(this.rebuilder_polls[server]);
351 document.getElementById(server).innerText = '🚫 %s'.format(
352 server
353 );
354 }
355 }
356 });
357 },
358
359 handleRebuilder: function (content, data, firmware) {
360 this.rebuilder_polls = {};
361 for (let rebuilder of data.rebuilder) {
362 this.rebuilder_polls[rebuilder] = L.bind(
363 this.handleRequest,
364 this,
365 rebuilder,
366 false,
367 content,
368 data,
369 firmware
370 );
371 poll.add(this.rebuilder_polls[rebuilder], 5);
372 document.getElementById(
373 'rebuilder_status'
374 ).innerHTML += `<p id="${rebuilder}">⏳ ${rebuilder}</p>`;
375 }
376 poll.start();
377 },
378
379 handleInstall: function (url, keep, sha256) {
380 ui.showModal(_('Downloading...'), [
381 E(
382 'p',
383 { class: 'spinning' },
384 _('Downloading firmware from server to browser')
385 ),
386 ]);
387
388 request
389 .get(url, {
390 headers: {
391 'Content-Type': 'application/x-www-form-urlencoded',
392 },
393 responseType: 'blob',
394 })
395 .then((response) => {
396 let form_data = new FormData();
397 form_data.append('sessionid', rpc.getSessionID());
398 form_data.append('filename', '/tmp/firmware.bin');
399 form_data.append('filemode', 600);
400 form_data.append('filedata', response.blob());
401
402 ui.showModal(_('Uploading...'), [
403 E(
404 'p',
405 { class: 'spinning' },
406 _('Uploading firmware from browser to device')
407 ),
408 ]);
409
410 request
411 .get(`${L.env.cgi_base}/cgi-upload`, {
412 method: 'PUT',
413 content: form_data,
414 })
415 .then((response) => response.json())
416 .then((response) => {
417 if (response.sha256sum != sha256) {
418 ui.showModal(_('Wrong checksum'), [
419 E(
420 'p',
421 _('Error during download of firmware. Please try again')
422 ),
423 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
424 ]);
425 } else {
426 ui.showModal(_('Installing...'), [
427 E(
428 'p',
429 { class: 'spinning' },
430 _('Installing the sysupgrade. Do not unpower device!')
431 ),
432 ]);
433
434 L.resolveDefault(callUpgradeStart(keep), {}).then((response) => {
435 if (keep) {
436 ui.awaitReconnect(window.location.host);
437 } else {
438 ui.awaitReconnect('192.168.1.1', 'openwrt.lan');
439 }
440 });
441 }
442 });
443 });
444 },
445
446 handleCheck: function (data, firmware) {
447 this.request_hash = '';
448 let { url, revision, advanced_mode, branch } = data;
449 let { version, target, profile, packages } = firmware;
450 let candidates = [];
451
452 const endpoint = version.endsWith('SNAPSHOT') ? `revision/${version}/${target}` : 'overview';
453 const request_url = `${url}/api/v1/${endpoint}`;
454
455 ui.showModal(_('Searching...'), [
456 E(
457 'p',
458 { class: 'spinning' },
459 _('Searching for an available sysupgrade of %s - %s').format(
460 version,
461 revision
462 )
463 ),
464 ]);
465
466 L.resolveDefault(request.get(request_url)).then((response) => {
467 if (!response.ok) {
468 ui.showModal(_('Error connecting to upgrade server'), [
469 E(
470 'p',
471 {},
472 _('Could not reach API at "%s". Please try again later.').format(
473 response.url
474 )
475 ),
476 E('pre', {}, response.responseText),
477 E('div', { class: 'right' }, [
478 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
479 ]),
480 ]);
481 return;
482 }
483 if (version.endsWith('SNAPSHOT')) {
484 const remote_revision = response.json().revision;
485 if (
486 get_revision_count(revision) < get_revision_count(remote_revision)
487 ) {
488 candidates.push([version, remote_revision]);
489 }
490 } else {
491 const latest = response.json().latest;
492
493 // ensure order: newest to oldest release
494 latest.sort().reverse();
495
496 for (let remote_version of latest) {
497 let remote_branch = get_branch(remote_version);
498
499 // already latest version installed
500 if (version == remote_version) {
501 break;
502 }
503
504 // skip branch upgrades outside the advanced mode
505 if (branch != remote_branch && advanced_mode == 0) {
506 continue;
507 }
508
509 candidates.unshift([remote_version, null]);
510
511 // don't offer branches older than the current
512 if (branch == remote_branch) {
513 break;
514 }
515 }
516 }
517
518 // allow to re-install running firmware in advanced mode
519 if (advanced_mode == 1) {
520 candidates.unshift([version, revision]);
521 }
522
523 if (candidates.length) {
524 let s, o;
525
526 let mapdata = {
527 request: {
528 profile,
529 version: candidates[0][0],
530 packages: Object.keys(packages).sort(),
531 },
532 };
533
534 let map = new form.JSONMap(mapdata, '');
535
536 s = map.section(
537 form.NamedSection,
538 'request',
539 '',
540 '',
541 'Use defaults for the safest update'
542 );
543 o = s.option(form.ListValue, 'version', 'Select firmware version');
544 for (let candidate of candidates) {
545 if (candidate[0] == version && candidate[1] == revision) {
546 o.value(
547 candidate[0],
548 _('[installed] %s').format(
549 candidate[1]
550 ? `${candidate[0]} - ${candidate[1]}`
551 : candidate[0]
552 )
553 );
554 } else {
555 o.value(
556 candidate[0],
557 candidate[1] ? `${candidate[0]} - ${candidate[1]}` : candidate[0]
558 );
559 }
560 }
561
562 if (advanced_mode == 1) {
563 o = s.option(form.Value, 'profile', _('Board Name / Profile'));
564 o = s.option(form.DynamicList, 'packages', _('Packages'));
565 }
566
567 L.resolveDefault(map.render()).then((form_rendered) => {
568 ui.showModal(_('New firmware upgrade available'), [
569 E(
570 'p',
571 _('Currently running: %s - %s').format(
572 version,
573 revision
574 )
575 ),
576 form_rendered,
577 E('div', { class: 'right' }, [
578 E('div', { class: 'btn', click: ui.hideModal }, _('Cancel')),
579 ' ',
580 E(
581 'button',
582 {
583 class: 'btn cbi-button cbi-button-positive important',
584 click: ui.createHandlerFn(this, function () {
585 map.save().then(() => {
586 this.applyPackageChanges({
587 url,
588 target,
589 version: mapdata.request.version,
590 packages: mapdata.request.packages,
591 }).then((packages) => {
592 const content = {
593 ...firmware,
594 packages: packages,
595 version: mapdata.request.version,
596 profile: mapdata.request.profile
597 };
598 this.pollFn = L.bind(function () {
599 this.handleRequest(url, true, content, data, firmware);
600 }, this);
601 poll.add(this.pollFn, 5);
602 poll.start();
603 });
604 });
605 }),
606 },
607 _('Request firmware image')
608 ),
609 ]),
610 ]);
611 });
612 } else {
613 ui.showModal(_('No upgrade available'), [
614 E(
615 'p',
616 _('The device runs the latest firmware version %s - %s').format(
617 version,
618 revision
619 )
620 ),
621 E('div', { class: 'right' }, [
622 E('div', { class: 'btn', click: ui.hideModal }, _('Close')),
623 ]),
624 ]);
625 }
626 });
627 },
628
629 load: async function () {
630 const promises = await Promise.all([
631 L.resolveDefault(callPackagelist(), {}),
632 L.resolveDefault(callSystemBoard(), {}),
633 L.resolveDefault(fs.stat('/sys/firmware/efi'), null),
634 uci.load('attendedsysupgrade'),
635 ]);
636 const data = {
637 url: uci.get_first('attendedsysupgrade', 'server', 'url'),
638 branch: get_branch(promises[1].release.version),
639 revision: promises[1].release.revision,
640 efi: promises[2],
641 advanced_mode: uci.get_first('attendedsysupgrade', 'client', 'advanced_mode') || 0,
642 rebuilder: uci.get_first('attendedsysupgrade', 'server', 'rebuilder')
643 };
644 const firmware = {
645 client: 'luci/' + promises[0].packages['luci-app-attendedsysupgrade'],
646 packages: promises[0].packages,
647 profile: promises[1].board_name,
648 target: promises[1].release.target,
649 version: promises[1].release.version,
650 diff_packages: true,
651 filesystem: promises[1].rootfs_type
652 };
653 return [data, firmware];
654 },
655
656 render: function (response) {
657 const data = response[0];
658 const firmware = response[1];
659
660 return E('p', [
661 E('h2', _('Attended Sysupgrade')),
662 E(
663 'p',
664 _(
665 'The attended sysupgrade service allows to upgrade vanilla and custom firmware images easily.'
666 )
667 ),
668 E(
669 'p',
670 _(
671 'This is done by building a new firmware on demand via an online service.'
672 )
673 ),
674 E(
675 'p',
676 _('Currently running: %s - %s').format(
677 firmware.version,
678 data.revision
679 )
680 ),
681 E(
682 'button',
683 {
684 class: 'btn cbi-button cbi-button-positive important',
685 click: ui.createHandlerFn(this, this.handleCheck, data, firmware),
686 },
687 _('Search for firmware upgrade')
688 ),
689 ]);
690 },
691 handleSaveApply: null,
692 handleSave: null,
693 handleReset: null,
694 });